既然遊戲中有關卡的設定,所以我想應該可以加個存檔的機制,所以接下來要解決回來繼續玩這件事,需要加入主選單、暫停選單,以及能夠儲存目前關卡與角色狀態的存檔。這篇記錄我如何在 Bevy 裡落實一個最小可行的進度系統。
這回的重點:
GameSession
資源與 SessionPlugin
,集中管理主選單、暫停選單、遊戲階段狀態。GameSession::is_playing()
,避免暫停時還能操作。GameSaveData
,序列化玩家 HP / 等級 / 裝備與關卡索引,寫入 saves/slot1.json
。new_demo/src/resources/game_session.rs
新增 GameSession
資源,負責記錄目前階段與選單實體位置:
#[derive(Resource, Debug, Default)]
pub struct GameSession {
phase: GamePhase,
pub main_menu_root: Option<Entity>,
pub pause_menu_root: Option<Entity>,
}
impl GameSession {
pub const SAVE_DIRECTORY: &'static str = "saves";
pub const SAVE_SLOT_FILE: &'static str = "saves/slot1.json";
pub fn phase(&self) -> GamePhase {
self.phase
}
pub fn set_phase(&mut self, phase: GamePhase) {
self.phase = phase;
}
pub fn is_playing(&self) -> bool {
matches!(self.phase, GamePhase::Playing)
}
}
GamePhase
很單純:MainMenu
/ Playing
/ Paused
。SessionPlugin
在 Startup
建立主選單,並在 Update
週期內處理各種互動:
app.init_resource::<GameSession>()
.add_event::<StartNewGameEvent>()
.add_event::<RequestLoadGameEvent>()
.add_event::<RequestSaveGameEvent>()
.add_event::<ResumeGameplayEvent>()
.add_systems(Startup, spawn_main_menu)
.add_systems(
Update,
(
handle_main_menu_interactions,
activate_gameplay_after_start.after(handle_main_menu_interactions),
handle_pause_menu_interactions,
process_save_game_requests.after(handle_pause_menu_interactions),
process_load_game_requests
.after(handle_main_menu_interactions)
.after(handle_pause_menu_interactions)
.after(process_save_game_requests),
toggle_pause_menu_on_escape,
resume_gameplay
.after(handle_pause_menu_interactions)
.after(toggle_pause_menu_on_escape)
.after(process_load_game_requests),
),
);
StartNewGameEvent
,轉為 Playing
並拆除選單畫面。Esc
會切換暫停選單;在暫停狀態下排除大部分輸入,等玩家按下 Resume 再繼續。new_demo/src/main.rs
,讓 SessionPlugin
成為整個遊戲啟動時的第一個插件。為了讓 UI 在 Pause 時不被遮蔽,我另外把選單的字型改成支援 CJK 的 IBMPlexSansJP-Regular.ttf
(assets/fonts/
),並更新 MENU_FONT_PATH
常數,確保「新遊戲」與「讀取進度」可以正確顯示中文。
有了 GameSession::is_playing()
之後,所有會受到暫停影響的系統都要先檢查狀態。例如 movement_system
:
pub fn movement_system(
keyboard_input: Res<ButtonInput<KeyCode>>,
session: Res<GameSession>,
mut query: Query<( &mut Transform, &mut Velocity, &mut PlayerFacing, &mut InputVector ),
(With<Player>, Without<PlayerDead>)>,
time: Res<Time>,
) {
if !session.is_playing() {
return;
}
// 原本的移動邏輯...
}
同樣的判斷也套用在包含攻擊、耐力回復、中毒計時、 Debug 熱鍵等系統上。特別是敵人 AI、魔法球移動與接觸傷害也都改成先檢查 is_playing()
——這解決了進入主選單或暫停時,史萊姆仍會追過來偷打的 bug,現在 Pause 菜單真正做到「全體靜止」。好處是:
GameSession
變成所有 gameplay 系統共享的 gating,後續要做過場動畫或多人暫停也可以沿用這種 pattern。input_system
也先檢查 session.is_playing()
,避免暫停時還能互動傳送門或開箱,整個遊戲流程因此保持一致。
進度檔使用 serde + serde_json
,new_demo/Cargo.toml
因此加入相關依賴。資料結構在 src/resources/save_data.rs
:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameSaveData {
pub version: u32,
pub level_index: usize,
pub player_health: i32,
pub player_max_health: i32,
pub player_level: usize,
pub player_experience: u32,
pub equipped_weapon: Option<WeaponKind>,
pub equipped_shield: Option<ShieldKind>,
}
WeaponKind
、ShieldKind
也各自 derive Serialize/Deserialize
。目前只開一個 slot1.json
,資料夾為 saves/
;若未來要做多個存檔就能沿用這份結構。
Pause 選單按下「儲存進度」時會觸發 process_save_game_requests
:
if matches!(session.phase(), GamePhase::MainMenu) {
warn!("主選單狀態下無法儲存進度");
return;
}
let mut data = GameSaveData::new();
data.level_index = level_state.current_index();
data.player_health = health.current;
data.player_max_health = health.max;
data.player_level = progression.level;
data.player_experience = progression.experience;
data.equipped_weapon = weapon.map(|w| w.kind);
data.equipped_shield = shield.map(|s| s.kind);
fs::create_dir_all(GameSession::SAVE_DIRECTORY)?;
fs::write(GameSession::SAVE_SLOT_FILE, serde_json::to_string_pretty(&data)?)?;
Playing
、Paused
階段才允許儲存,避免一開始沒有玩家就寫出空檔案。serde_json::to_string_pretty
讓檔案易讀,方便日後 Debug。讀檔按鈕(主選單或暫停選單)會觸發 RequestLoadGameEvent
,由 process_load_game_requests
接手:
slot1.json
的時候會直接顯示警告,如果是在主選單觸發會把主選單重建回來。1
,不合的時候先警告再嘗試載入。LevelState::set_current_index()
+ LevelBuildContext.pending_layout = Some(index)
讓關卡系統重新排程該關。PlayerProgression
、Health
、Attack
、Defense
,並重新載入對應階段的玩家 sprite。weapon_events.write(WeaponEquipEvent { kind })
讓既有的裝備系統處理貼圖 / 攻擊加成。PlayerDead
、Poisoned
、死亡畫面 overlay,確保讀檔後角色是可操控狀態。session.set_phase(GamePhase::Playing)
。info!
印出目前關卡、HP、裝備,方便在 log 追蹤。讀檔流程最棘手的是與關卡系統的配合。因為原本就有 LevelBuildContext
控制排程,所以只要塞回 pending_layout
即可沿用既有的流程,不需要另外手動產生地板/敵人。
目前已經可以做到單一存檔,而且也能讀取檔案,後續如果想要多加一些功能的話,可能會加上這些:
今日程式碼同步至 repo